fix: serve public files in prod#766
Conversation
commit: |
|
/bigbonk review |
There was a problem hiding this comment.
Review: fix: serve public files in prod
Nice contribution! The signal-based approach (RSC handler returns x-vinext-static-file header, prod server intercepts and serves the actual file) is a clean architecture that keeps the RSC entry runtime-agnostic while letting the Node.js prod server handle actual file I/O. The test coverage is good — both the simple case and the basePath case are tested.
I have a few concerns, mostly around the Cloudflare Workers gap and a potential security issue with the basePath check placement.
Cloudflare Workers gap
The x-vinext-static-file signal is only consumed by prod-server.ts (Node.js). The Cloudflare worker entries (app-router-entry.ts, deploy-generated workers) do not intercept this signal. On Cloudflare, this works by accident because Cloudflare's static asset layer serves files from dist/client/ before the worker runs. But:
- If
not_found_handlingis set to something other than"none", or if the static asset binding configuration changes, the signal Response (null body + header) would be returned to the client as-is. - The PR description says "we cannot access static files in the public folder" but doesn't specify which runtime. If this is a Node.js-only fix, that should be documented. If Cloudflare is also affected, the worker entry needs a handler too.
This isn't necessarily a blocker (it may already work on CF), but it should be documented or addressed.
Summary of inline comments below
- Security concern: The basePath 404 guard may reject requests that arrive before the basePath check but need to reach middleware (e.g., health checks, load balancer probes at
/). This matches Next.js behavior, but worth confirming. - Cache invalidation:
scanPublicFileRoutesuses a module-level cache keyed bypublicDirpath, but the cache is never invalidated. In dev mode with watch, new files added topublic/won't be reflected in__publicFiles. node_modulesskip in walk: Thenode_modulesskip inscanPublicFileRoutesis unusual —public/shouldn't containnode_modules. If it does, something is wrong with the project setup.- Minor: The
_mwCtx.statusfallback in__createStaticFileSignaluses 200, but static file responses should arguably use whatever status the static file server determines (could be 304 for conditional requests).
|
The review has been submitted. Here's a summary of the feedback I posted: Overall: The signal-based architecture ( Key concerns raised:
|
|
comments resolved |
|
/bigbonk review |
There was a problem hiding this comment.
Review: fix: serve public files in prod
The signal-based architecture is solid — the RSC handler identifies public file requests and returns a lightweight signal response (x-vinext-static-file header), which is then resolved by the appropriate server layer (Node.js prod-server or Cloudflare worker). Good separation of concerns. Tests are thorough: unit tests for scanPublicFileRoutes, resolveStaticAssetSignal, tryServeStatic status override, plus integration tests for both plain and basePath scenarios.
I have one concrete bug (double resolution in deploy-generated workers) and a few smaller items.
Key issues
-
Double
resolveStaticAssetSignalcall in deploy-generated workers —app-router-entry.tsresolves the signal whenenv.ASSETSexists, and then the deploy-generated worker indeploy.tscallsresolveStaticAssetSignalagain on the already-resolved response. It's not a correctness bug (the header is stripped after the first call, so the second call returnsnull), but it means every static file request does an unnecessaryASSETS.fetch()→ resolve → header-scan cycle, and it makes the code harder to reason about. Eitherapp-router-entry.tsshould not resolve when it knows a wrapping worker will do it, ordeploy.tsshould trust thatapp-router-entry.tsalready resolved. -
statusOverridelogic when asset fetch fails — InresolveStaticAssetSignal,statusOverrideis only applied whenassetResponse.ok. IfASSETS.fetchreturns a 404, the signal's middleware status is silently dropped and the 404 is returned as-is. This is probably the correct behavior, but it's worth a comment explaining the intent.
Minor items
- The
scanPublicFileRoutessymlink loop detection viavisitedDirsis a nice touch. - Test coverage for the basePath guard (
!hasBasePath && !/__vinext/) is good — it's a meaningful behavioral change bundled into this PR.
| fetchAsset: (path) => | ||
| Promise.resolve(env.ASSETS!.fetch(new Request(new URL(path, request.url)))), | ||
| }); | ||
| if (assetResponse) return assetResponse; |
There was a problem hiding this comment.
Bug: double signal resolution with deploy-generated workers.
The deploy-generated worker entry (deploy.ts line ~502) calls handler.fetch(request, env, ctx) — which is this file. Since the deploy worker passes env (which has ASSETS), this block resolves the signal and returns a fully-formed asset response. Then back in the deploy worker, resolveStaticAssetSignal is called again on that response.
The second call is a no-op (header is stripped), but it still:
- Scans headers looking for
x-vinext-static-file - Allocates an unnecessary function call +
buildHeaderRecord
More importantly, it's confusing — two layers both claim responsibility for the same signal.
Consider gating this on a condition that indicates no outer wrapper will handle it, or remove the signal handling from one of the two layers. The simplest fix: since app-router-entry.ts is the shared entry used by both direct wrangler usage and deploy-generated workers, and the deploy worker always wraps it, maybe this block should only run when there's no outer resolveStaticAssetSignal caller. One approach: only resolve here if there is no image optimization handler wrapping this (i.e., the user is using app-router-entry directly in wrangler.jsonc without a custom worker).
Alternatively, just remove the signal resolution from deploy.ts since app-router-entry.ts already handles it.
| cancelResponseBody(signalResponse); | ||
| const assetResponse = await options.fetchAsset(assetPath); | ||
| const statusOverride = | ||
| assetResponse.ok && signalResponse.status !== 200 ? signalResponse.status : undefined; |
There was a problem hiding this comment.
The intent of this condition could use a comment. When assetResponse.ok is false (e.g., 404 from the asset layer), the middleware status is dropped and the asset's error status is returned directly. When the asset is found but middleware set a non-200 status (e.g., 403 via rewrite), the middleware status overrides.
This seems correct (don't override a real 404 with a middleware 403), but it's non-obvious. A one-liner would help:
| assetResponse.ok && signalResponse.status !== 200 ? signalResponse.status : undefined; | |
| // Only override the asset status with the middleware status when the asset was | |
| // successfully fetched — if the asset itself errored (404, 500), preserve that. | |
| const statusOverride = | |
| assetResponse.ok && signalResponse.status !== 200 ? signalResponse.status : undefined; |
| ${ | ||
| bp | ||
| ? ` | ||
| if (!hasBasePath(pathname, __basePath) && !pathname.startsWith("/__vinext/")) { | ||
| return new Response("Not Found", { status: 404 }); | ||
| } |
There was a problem hiding this comment.
This basePath 404 guard is a meaningful behavioral change beyond "serve public files." Previously, requests without the basePath prefix could fall through to catch-all routes or middleware; now they get a hard 404.
This matches Next.js behavior and is probably correct, but it should be called out in the PR description since it changes observable behavior for any app with basePath configured. The /__vinext/ exception is good — internal endpoints need to bypass.
Worth noting: this also blocks middleware from seeing non-basePath requests. In Next.js, middleware's matcher config controls this, but here the 404 fires before middleware runs. This is consistent with how Next.js handles it (basePath rejection is early), but should be verified.
| if ( | ||
| (request.method === "GET" || request.method === "HEAD") && | ||
| !pathname.endsWith(".rsc") && | ||
| __publicFiles.has(cleanPathname) |
There was a problem hiding this comment.
Nit: the .rsc suffix check prevents RSC data fetches from being intercepted, which is correct. But there's no guard against POST requests to public file paths that happen to match (e.g., a form submission to /logo/logo.svg). The GET || HEAD check handles this — just confirming that's intentional and sufficient.
Also, cleanPathname here is post-middleware-rewrite, which means middleware can rewrite any path to serve a public file. This matches Next.js semantics (middleware rewrites are applied before filesystem route matching).
| staticResponseHeaders, | ||
| response.status, | ||
| ); | ||
| cancelResponseBody(response); |
There was a problem hiding this comment.
Good: cancelResponseBody is called after tryServeStatic regardless of success. This correctly cleans up the signal response's body (which is null anyway, but defensive).
One edge case: if tryServeStatic throws (e.g., filesystem error mid-stream after headers are sent), the response body of the signal is still cancelled, but res may be in an inconsistent state. The outer try/catch at line 1100 handles this with a 500 fallback, so it should be fine.
packages/vinext/src/deploy.ts
Outdated
| return handler.fetch(request, env, ctx); | ||
| const response = await handler.fetch(request, env, ctx); | ||
| return ( | ||
| (await resolveStaticAssetSignal(response, { |
There was a problem hiding this comment.
As noted on app-router-entry.ts, this resolveStaticAssetSignal call is redundant when app-router-entry.ts already resolves the signal (because env is passed through to handler.fetch). The signal header is stripped by the first resolution, so this second call always returns null.
If the intent is for the deploy worker to be the resolution point (since it knows about env.ASSETS), then app-router-entry.ts should not resolve when env.ASSETS is present. If the intent is for app-router-entry.ts to handle it (so direct wrangler usage works), then this block can be removed.
|
Review posted. Here's a summary of the feedback: Main issue: Double Other comments:
|
|
resolved |
|
/bigbonk review |
There was a problem hiding this comment.
Review: fix: serve public files in prod
The signal-based approach is well-designed: the RSC handler identifies public file requests via the __publicFiles Set, returns a lightweight signal response with the x-vinext-static-file header, and the appropriate server layer (Node.js prod-server or Cloudflare app-router-entry.ts) resolves the signal into an actual file serve. This keeps the RSC entry runtime-agnostic while each deployment target handles file I/O in its native way.
The previous review concerns (double resolution in deploy-generated workers, cache staleness, symlink handling) have all been addressed. CI is fully green.
A few minor observations below, none blocking.
| // afterFiles/fallback rewrites, matching Next.js routing semantics. | ||
| if ( | ||
| (request.method === "GET" || request.method === "HEAD") && | ||
| !pathname.endsWith(".rsc") && |
There was a problem hiding this comment.
Nit: pathname here (with .rsc suffix intact) vs cleanPathname (.rsc stripped) is a subtle distinction that's easy to misread. The logic is correct — pathname retains .rsc so the guard rejects RSC data fetches, while cleanPathname is the actual filesystem path to look up — but a brief inline comment would help future readers understand why two different variables are used in the same condition.
| !pathname.endsWith(".rsc") && | |
| // Guard: pathname retains the .rsc suffix (if any) so we can reject RSC data fetches; | |
| // cleanPathname has it stripped and is the actual filesystem path to match. | |
| if ( | |
| (request.method === "GET" || request.method === "HEAD") && | |
| !pathname.endsWith(".rsc") && | |
| __publicFiles.has(cleanPathname) | |
| ) { |
| staticResponseHeaders, | ||
| response.status, | ||
| ); | ||
| cancelResponseBody(response); |
There was a problem hiding this comment.
Minor: cancelResponseBody(response) is called after tryServeStatic regardless of whether serving succeeded. Since the signal response has a null body, this is a no-op, but if the signal construction ever changes (e.g., someone adds a body for debugging), the cancel call at this position is correct and defensive. Good.
| const request = nodeToWebRequest(req, normalizedUrl); | ||
| const response = await rscHandler(request); | ||
|
|
||
| const staticFileSignal = response.headers.get("x-vinext-static-file"); |
There was a problem hiding this comment.
The x-vinext-static-file header name is used as a string literal in three separate files (app-rsc-entry.ts template, prod-server.ts, worker-utils.ts). If this protocol ever changes, it's easy to miss one. Consider extracting it into a shared constant (e.g., in worker-utils.ts) that both prod-server.ts and worker-utils.ts can import, and that the entry template can reference via JSON.stringify().
Not blocking — the current test coverage (deploy.test.ts, app-router.test.ts, serve-static.test.ts) would catch a mismatch.
| const result = await (ctx ? runWithExecutionContext(ctx, handleFn) : handleFn()); | ||
|
|
||
| if (result instanceof Response) { | ||
| if (env?.ASSETS) { |
There was a problem hiding this comment.
Clean approach — app-router-entry.ts is the single resolution point for the static asset signal on Cloudflare, and the deploy-generated worker (which wraps this entry) correctly delegates without its own resolution. The test at deploy.test.ts line 396-402 explicitly guards against the double-resolution regression. Well done.
|
Review submitted with approval. Here's a summary of what I posted: Verdict: Approved The signal-based architecture is clean — the RSC handler identifies public file requests via a Previous review concerns (double resolution in deploy workers, cache staleness, symlink handling) were all addressed. All CI checks pass. Minor suggestions (non-blocking):
|
Currently, we cannot access static files in the public folder.